Add the virtual↔host PathMapping core with two facades over it#17
Merged
Conversation
Sandbox enforcement has two cooperative jobs - translation and confinement - and until now the translation half lived only inside SwiftBash's MountedFileSystem. Every cooperative caller that isn't a bash builtin (SwiftPorts CLIs, the JS runtime, SwiftScript) resolved paths in the wrong space: Shell.resolve returned virtual spellings it then fed to FileManager/C APIs, and the URL gate checked those virtual spellings against host roots. Cocoanetics/SwiftBash#83 moves the core down here so both facades share one authority: - PathMapping: the mount table (longest-virtual-prefix routing, lexical `..` collapse via the new Shell.normalizePath) translating virtual -> host, plus virtualPath(forHost:) folding host paths back to virtual for display so realpath-style output never leaks the embedder's host layout. - Sandbox.confined(to:home:temporaryDirectory:...): the Facade-B gate. Authorizes a file URL iff its canonical (symlink-resolved) path lands inside one of the mapping's host roots; carries the mapping as Sandbox.pathMapping. Region directories anchor at the `home` mount's host root; temporaryDirectory defaults to the `/tmp` mount's host. - Shell.resolve now translates through sandbox?.pathMapping after the cwd join, so SwiftPorts CLIs / JS / SwiftScript get host paths that do real I/O on the right files with no per-tool rewrite; the same table gates them, so resolution and confinement cannot disagree. Shell.currentDirectory translates identically. Without a mapping (standalone CLIs, rooted/appContainer sandboxes) nothing changes. - Shell.displayPath(for:): the outbound door for anything a script gets to see. Shell.normalizePath is the lexical normaliser SwiftBash carried; it moves down so the mapping (and the subclass) share one implementation.
This was referenced Jun 11, 2026
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 688c937625
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
SwiftLint CI: file_length on Sandbox+Factories (confined(to:) moves to its own file, sharing the now-internal authorizeUnderRoots), type_body_length on PathMappingTests (the confined-gate suite moves to SandboxConfinedTests), and large_tuple in virtualPath(forHost:) (the best-match accumulator becomes a small local struct).
SwiftBash catalogues the SwiftPorts CLIs (fd, rg, yq, the swift-js / node surface) under the virtual /usr/local/bin tier, and its own runtime env default is /usr/local/bin:/usr/bin:/bin. The synthetic environment - used by exactly the sandboxed runs those ports are registered for - omitted that tier, so `fd` came back "command not found" inside `swift-bash exec --sandbox` while jq (catalogued under /usr/bin) worked. Align the synthetic PATH with the catalogue.
Codex P1 on #17: under a mapping, resolve returned paths that matched no mount unchanged - so a script holding (or guessing) the HOST spelling of a mounted directory could address files through it, and the gate authorized it since the location genuinely lies inside a canonical host root. The containment boundary held; the namespace boundary (scripts speak virtual only) didn't - and Facade B disagreed with the bash-side mounted filesystem, which ENOENTs host spellings. resolve now never hands back un-translated text under a mapping: paths outside every mount (including the Windows drive-letter and empty-cwd fallbacks) resolve to a voided location under Shell.unmappedPathSentinel ("/dev/null/unmapped" + the normalised virtual spelling). /dev/null is a file on every POSIX host, so nothing below it can exist or be created, and the gate's containment check rejects it like any other out-of-roots path. displayPath folds the sentinel back to the original spelling so diagnostics stay readable. Tests cover the host-spelling probe (verbatim and symlink-resolved), gate denial, and ..-escape of the sentinel.
Shell.swift crossed the 400-line file_length cap with the sentinel addition; the resolve/displayPath/sentinel family lives with normalizePath now, which is the better home anyway.
On Linux the platform temp root IS /tmp, so a fixture host dir under it prefix-matches a virtual /tmp mount and translates as an ordinary virtual path (into the mount's own backing - harmless by design) instead of voiding. Probe with non-colliding virtual prefixes so the no-mount-matches contract is what's actually exercised on every platform; document the collision semantics in the test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The mapping + confinement core for cooperative sandboxing (Cocoanetics/SwiftBash#83), moved down from SwiftBash so every caller shares one authority:
PathMapping— the mount table. Longest-virtual-prefix routing, lexical./..collapse (viaShell.normalizePath, moved down from SwiftBash's interpreter so both normalise identically), andvirtualPath(forHost:)for folding host paths back to the script-visible spelling.Sandbox.confined(to:home:temporaryDirectory:allowedHosts:authorizeNetwork:)— the gate over a mapping: a file URL authorizes iff its canonical (symlink-resolved) path lands inside one of the mapping's host roots. Carries the mapping asSandbox.pathMapping. Region directories anchor at thehomemount's host root;temporaryDirectorydefaults to the/tmpmount's host root.Shell.resolve(_:)now translates throughsandbox?.pathMappingafter the CWD join — SwiftPorts CLIs / the JS runtime get host paths ready for Foundation/C I/O, gated by the same table, with no per-tool rewrite.Shell.currentDirectorytranslates identically. No mapping → no behavior change (standalone CLIs,rooted/appContainersandboxes are untouched).Shell.displayPath(for:)— the outbound door: fold host paths back to virtual for anything a script gets to see (realpathhygiene).Why
Sandbox enforcement has two cooperative jobs — translation and confinement — and the translation half lived only inside SwiftBash's
MountedFileSystem. Every cooperative caller that isn't a bash builtin resolved paths in the wrong space:Shell.resolvereturned virtual spellings that were then fed to FileManager/C APIs, and the URL gate checked those virtual spellings against host roots, passing only by string coincidence. One core, two doors (theFileSystemprotocol stays in SwiftBash as Facade A;resolve()is Facade B) is what keeps the two sides from disagreeing about where/tmp/foolives.Merge order
Merge this first. Cocoanetics/SwiftBash and Cocoanetics/SwiftPorts pin
ShellKitbranch: main; their companion PRs build on this and their CI stays red until this lands:Also in this PR
Environment.syntheticnow includes/usr/local/binin its default PATH — SwiftBash catalogues the SwiftPorts CLIs (fd, rg, …) under that virtual tier, and omitting it made them "command not found" in exactly the sandboxed runs they exist for (found by end-to-end smoke testing the SwiftBash PR).Tests
14 new tests in
PathMappingTests(translation both directions, longest-prefix rules,..collapse before routing,Shell.resolve/displayPathunder a mapping, gate containment incl. symlink escape + symlink-spelled mount roots, region/temp derivation, network policy). Full suite: 56/56.🤖 Generated with Claude Code